Skip to content

feat!: remove users.display_name, resolve identity from handle - #5

Merged
kipavy merged 9 commits into
mainfrom
feat/remove-display-name
Aug 16, 2026
Merged

feat!: remove users.display_name, resolve identity from handle#5
kipavy merged 9 commits into
mainfrom
feat/remove-display-name

Conversation

@kipavy

@kipavy kipavy commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Removes users.display_name entirely. users.handle becomes the sole human-facing identifier.

Why

The participant list broadcast every participant's email address. All four client call sites passed getCurrentUserEmail() as the WebSocket display_name query parameter, and the server echoed it to every participant — so a stranger admitted by a knock invite learned everyone's real email. The name is now resolved from users.handle by the authenticated user id, so it cannot be set over the wire.

That also removes the impersonation vector ("Voltius Support") that sanitize_display_name, MAX_DISPLAY_NAME_CHARS and the control-character refusal existed to defend. All three are deleted rather than hardened.

Unplanned bonus: get_invitation is unauthenticated and used to hand anyone holding an invite token the inviter's email local part. It now returns the handle.

The alias window

For this release the server still emits display_name JSON keys whose value is the handle, so clients that have not yet updated render a handle rather than blank rows. Every one carries a comment. They all come out in 0.27display_name (×5 structs), invited_by_display_name, inviter_display_name, the plugin API's displayName, the WsQuery.display_name sink, and the ACCOUNT_CACHE_KEYS purge entry.

PendingInvitation.display_name is not an alias — it is the deliberate exception, populated by COALESCE(invitee.handle, pi.email), because an invitee with no account has no handle. That is what keeps a handle-only roster mappable back to a person.

⚠️ Deploy is order-sensitive and irreversible

  1. Ship the web portal change first (VoltiusApp/web) — it removes a live Save button that 404s once this lands.
  2. Stop the old server, then start the new one. Migrations run in-process at startup, so the new container applies 036 the instant it boots. No rolling restart — the old binary's register and /v1/auth/me fail the moment the column is gone.
  3. No rollback past this point. No down-migration, and the previous image cannot serve the post-036 schema. Verify the new image boots against a copy of the production schema first.
  4. Do not ship the client before the server.
  5. Verify by SQL literal, not endpoint behaviour — the aliases make responses look identical either way.

Testing

240 tests, cargo clippy --all-targets -- -D warnings clean. Migration 036 applied to a fresh database and verified: display_name absent, handle + handle_is_custom present.

kipavy added 9 commits August 15, 2026 16:54
The participant list carried a caller-supplied display_name, which every
client populated with the user's own email address. A stranger admitted by
knock therefore learned every participant's real email. The name is now read
from users.handle by the authenticated user id, so it cannot be set over the
wire — which also removes the impersonation vector sanitize_display_name and
MAX_DISPLAY_NAME_CHARS existed to defend, and both are deleted with it.

display_name stays in the emitted JSON as an alias carrying the handle, so
pre-0.26 clients keep rendering a name. Delete in 0.27.
Two comments near invited_by_handle still described display_name as
caller-supplied — no longer true after resolving it server-side from
users.handle. Rewritten to state what the code does now.

Also found while re-running the suite: the new handle-resolution test used
a fixed literal handle, which collides with itself on a second run against
a persistent test database (users.handle is unique and never recycled).
Switched to test_support::unique_handle, matching the rest of the suite.
The prior wording claimed the participant list "isn't available yet" —
wrong; it's populated but deliberately redacted to empty for an unaccepted
stranger in list_active_sessions. Point at that instead of restating it.
Every query that selected display_name selects handle. The display_name JSON
key survives as an alias carrying the handle for pre-0.26 clients; delete in
0.27. A pending invitation to an address with no account still shows the
email, which is what keeps a handle-only roster mappable to a person.
Teammate fuzzy matching survives on email, which is the half that matters —
a teammate's address is already known to a teammate. Custom-handle fuzzy and
the two exact branches are unchanged.
There is nothing left to set. /v1/auth/me reports the handle, under the
display_name alias as well, for pre-0.26 clients.
The column is gone. Every display_name that survives in the API is an alias
carrying the handle, for clients that have not yet updated; all of them are
deleted in 0.27.
…field

036 drops the column with no down-migration and no room for a rolling
restart across it; record that on the migration itself. UserSearchResult's
display_name was the one alias that landed (Task 3) without the comment
the others use, so it would have survived the 0.27 grep undetected.
…_name

DROP COLUMN IF EXISTS makes a manual replay of 036 safe; production has
never run it. PendingInvitation.display_name is the one server field
that is not a pre-0.26 alias — mark it as the deliberate exception so
a 0.27 sweep does not read its silence as a missed deletion.
@kipavy
kipavy merged commit 83e8df4 into main Aug 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant